Repository navigation
fix(mongodb): retry a write conflict on the stream sequence counter - #475
Open
LeeroyHannigan wants to merge 12 commits into
Open
LeeroyHannigan wants to merge 12 commits into
LeeroyHannigan wants to merge 12 commits into
Conversation
On a table with a stream enabled, every write draws a sequence number from a per-shard counter inside the write's transaction. Two writes on the same shard conflict on that counter, MongoDB aborts one with WriteConflict (code 112) and the TransientTransactionError label, and next_sequence_number_in_session mapped the error to StorageError::Internal. The transaction retry loop never saw the conflict and the request answered HTTP 500. The same mapping sat on the index entry writes, the shard lookup and the stream record insert. A new write_conflict::transaction_error classifies a driver error raised inside a write transaction: a transient write conflict becomes StorageError::Transient, anything else stays Internal. The in-session helpers use it, and TxErr and TransactOpError turn a Transient error from a helper into a re-run of the transaction, with the same backoff and budget as a conflict on the data collection. The UnknownTransactionCommitResult label is not transient and is still classified by the commit path alone. A PutItem or DeleteItem that runs out of its retry budget now returns StorageError::Transient, which the engine answers as HTTP 503 ServiceUnavailable, instead of a 400 TransactionConflictException. Amazon DynamoDB answers a single-item write with TransactionConflictException only while a TransactWriteItems holds the item, which this backend cannot tell apart from a race against other single-item writes. The DynamoDB error-handling documentation lists ServiceUnavailable (HTTP 503) as "OK to retry? Yes", and the SDK retry modes retry a 503 on their own, whereas a 400 is handed to the application. UpdateItem keeps its own exhaustion error. BatchWriteItem applied its requests one after another and answered any write error for the whole batch, so a batch whose first put had committed answered 500 and a later PutItem returned that put's item as its old image. The handler now keeps the error only when no request was applied. After a committed request, a write the backend could not apply now (a spent retry budget, a transient, connection or internal failure) and every request after it are reported in UnprocessedItems with a 200, which is what the service documents for a batch with individual failures. Consumed capacity and item collection metrics count the applied requests only. A request is applied at most once: the storage layer retries before commit and never re-runs a body after a commit of unknown outcome. Measured on 2026-10-23 against the service in us-east-1 with client retries off: PutItem and DeleteItem churn over 4 keys from 8 threads, 480 calls, all 200; BatchWriteItem racing PutItem on one key from 16 writers, 240 calls, all 200 and no UnprocessedItems. On MongoDB 7.0 (single-node replica set, client never retries): the same churn with 2,016 calls answered 286 PutItem and 136 DeleteItem with 500 on main and 0 on this branch; 16 writers racing BatchWriteItem against PutItem on one key, 480 calls: main answered 63 BatchWriteItem and 1 PutItem with 500 and 25 PutItem ALL_OLD images were items of a batch that had failed; this branch answered 8 BatchWriteItem and 7 PutItem with 503 and no such image. With 32 threads on the churn shape 94 of 1,024 PutItem calls still run out of the 50-attempt budget and answer 503; the budget is the subject of #426. tests/test_stream_write_contention.py fails on main (88 of 480 churn writes and 10 batch calls answered 500) and passes on this branch. Fixes #437
…retry budget UpdateItem returned TransactionConflict when its retry budget ran out, while PutItem and DeleteItem returned the transient error the engine answers as HTTP 503 ServiceUnavailable. One backend gave two answers to the same contention on the same item, and the 400 is the answer the application sees rather than the one the SDKs retry. UpdateItem now returns write_conflict::retry_budget_exhausted as well. TransactWriteItems keeps TransactionCanceledException with a per-operation TransactionConflict reason, which is what the service answers for a transaction. Measured on 2026-10-23 against the service in us-east-1, client retries off, on a table with a GSI and a stream: 16 writers x 20 UpdateItem ADD c :1 on one key answered 320 of 320 with 200, the stored counter equalled the number of calls that answered 200, and the 320 ALL_NEW counter values were 1 to 320, each returned to one caller; 16 writers x 20 SET l = list_append(l, :m) answered 320 of 320 with 200 and the stored list held each marker once. The service answers a single-item write with TransactionConflictException only while a TransactWriteItems holds the item, which none of these callers sent. BatchWriteItem deferred a failed write into UnprocessedItems only when an earlier request in the same batch had been applied, so the same contention answered 200 with UnprocessedItems or 503 for the whole batch depending on where the contended request sat in the batch. A deferrable failure now goes to UnprocessedItems wherever it sat, and a batch that could apply none of its requests answers 200 with every request in UnprocessedItems. The BatchWriteItem documentation puts a request that failed because the throughput was exceeded or an internal processing failure occurred into UnprocessedItems, lists the request faults that reject the whole batch (a missing table, a key that does not match the schema, duplicate keys, more than 25 requests, an item over 400 KB, a request over 16 MB, an oversized key), and names an error for a batch that processed nothing only for throttling, which this server does not account for. A storage fault is no longer deferred: is_deferrable drops StorageError::Internal, so an internal error keeps answering HTTP 500 instead of being handed to a client's UnprocessedItems loop, which a replay cannot clear. tests/test_stream_write_contention.py gains the UpdateItem shape above on a table with a GSI and a stream: no call answers a 400 TransactionConflictException, the counter matches the calls that answered 200, and every marker a caller was told committed is stored once. Its BatchWriteItem test now sends the contended request first as well as last, which is the order that answered 503 for the whole batch. The RFC's write-conflict paragraph said is_transient_write_conflict matches the UnknownTransactionCommitResult label. It does not, by design: under that label the commit may already have applied, so the body must not be re-run, and classify_commit_error handles it alone. Issue #437
The BatchWriteItem contention test sent the raced put after a side put in every round, the order in which an earlier request has already committed when the raced one fails. It now alternates: the raced put leads on even rounds and the side put on odd rounds, and a failure records which arm raised it and which order the round used. The writer count goes to 16, 8 batch writers racing 8 putters, the shape measured against the service (240 BatchWriteItem and 240 PutItem on one key, all 200). The batch arm must not fail in either order. A single-item PutItem that spends its whole retry budget answers 503 ServiceUnavailable, which the SDKs retry, so the put arm allows that one answer and no other. With the sources of 879a419 in the tree and this test file, over 8 runs against MongoDB 7.0: the batch test failed 2 runs, both with BatchWriteItem 503 ServiceUnavailable on a raced-first round, and the UpdateItem test failed 7 runs, with 1 to 25 of 640 calls answering 400 TransactionConflictException. With the fixes in the tree, 8 of 8 runs passed all three tests. Issue #437
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 23, 2026 23:15
This was referenced Oct 24, 2026
The handler looked up each table, checked its duplicate keys and validated each request immediately before writing that request. A write that could not be applied now (a spent retry budget or a connection fault) ended the loop and returned every later request in UnprocessedItems with a 200, so a later request that fails validation was never checked and came back as unprocessed. Without any contention the same order already applied the requests ahead of a faulty one and then answered 400, so a rejected batch could leave part of itself applied. Every table lookup, the duplicate-key check and the validation of every request now run before the first write, in the order the faults used to be reached. A fault rejects the whole batch with nothing applied, wherever the faulty request sits, which is what the BatchWriteItem reference documents for these faults. This also closes the partial application that preceded a later request's validation error. The writes run in a separate function that takes a DataEngine, so the handler tests drive it with a storage double. Tests: write_batch_tests has a double whose first put fails with Transient and a second request whose key does not match the schema (400, nothing written), the same fault in a later table, a missing later table, duplicate keys in a later table, and a valid batch written with each table's own key info. tests/test_batch_write_validation_order.py sends a valid put or delete ahead of an invalid put, a put to a missing table, or a table with duplicate keys, and requires a 400 with the valid request not applied. It needs no contention.
The batch handler defers TransactionConflict, which the Cassandra backend raises from a put when a transaction or an expiration claim holds the item. No measurement of the service backed that, so it was open whether the batch should defer the request or fail with TransactionConflictException. Measured on the service (us-east-1, 2026-10-27, client retries off): four writers ran TransactWriteItems on the same two items in a loop while four batch writers sent BatchWriteItem calls on them, two samples of 640 batch calls. About half of the transactions were cancelled by each other (107 of 222 and 125 of 240), so the items were held often. - A batch of two requests, one on a held item and one on an item no transaction touches, answered 200 with the held item's request in UnprocessedItems and the other request applied, in either order. The held item came first in 85 and 101 of the calls and second in 75 and 80 of them (first and second sample). The other item was present after all 640 calls. - A batch of two requests on two held items answered 200 with the one request that lost in UnprocessedItems in 10 and 21 calls, and 500 InternalServerError in 80 and 68 calls. No call answered 200 with both requests unprocessed. - A batch of one request on a held item answered 500 InternalServerError in 78 and 82 of 160 calls. - No BatchWriteItem call answered TransactionConflictException. So TransactionConflict stays deferrable, and the handler test pins the answer: a put that returns it after another request applied leaves exactly that request in UnprocessedItems with a 200. The Cassandra side of the pin is the existing storage test in ttl_integration.rs, which holds a claim on a row and asserts that a put on it returns TransactionConflict. The answer for a batch where nothing applied is a separate decision, taken in the next commit.
The handler stopped at the first request the backend could not apply and returned that request and every request after it in UnprocessedItems with a 200. Two things followed. The answer for a contended request depended on its position in the batch, and a batch of which no request applied answered 200 with every request unprocessed. The boto3 batch writer adds unprocessed items to its next flush with no pause, so under the contention that caused the failure such a client loops at full speed, and each pass costs the server another full retry budget. The service does neither (measured 2026-10-27 in us-east-1, client retries off, while a TransactWriteItems held the items). A batch of two requests in which one was on a held item answered 200 with that request unprocessed and the other applied, whichever came first; the other request applied in all 640 calls of both samples. A batch whose requests were all on held items, one request or two, answered 500 InternalServerError, and no call answered 200 with every request unprocessed. The handler now tries every request. A request that fails with an error a replay may clear is added to UnprocessedItems and the loop goes on. When no request applied, the batch answers 503 ServiceUnavailable, which the SDKs retry with backoff; the service's 500 for the same case is recorded in docs/differences-from-dynamodb.md. A connection error still ends the attempt, because the requests after it would wait on the same unreachable backend: the request that met it and the rest are returned unprocessed, or the batch answers 503 if nothing applied. Trade-off: each failed request spends its own retry budget before the batch answers, so a batch of 25 contended requests can spend 25 budgets in one call where it spent one before. The 50-attempt budget still bounds each of them. Tests (write_batch_deferral_tests): a failed first request does not stop the rest; UnprocessedItems holds exactly the failed requests per table and the consumed capacity and WriteCapacityUnits count the applied ones; a double that fails every put gives 503 after trying every request, for Transient and for TransactionConflict; a connection error leaves the rest unattempted, and with nothing applied answers 503 after one attempt; an internal error after a failed request still fails the batch with 500. The shared fixtures moved into scripted.rs next to the double. The stream contention test retries a batch that answers 503, as an SDK does.
When a single-item write ran out of attempts, the error said the write "lost 50 attempts to concurrent writers". The loops re-run a transaction for any error that a re-run may clear, which includes a primary step-down and a snapshot error, so a write that failed for those reasons was reported as write contention, and the last driver error was dropped. TxErr::Transient, TransactOpError::Transient and CommitOutcome::RerunBody now carry the driver's error text, and each loop keeps the last one. The exhaustion message reads "<operation>: could not commit in <N> attempts: <cause>", and the engine logs it with its 503 warning. Each retry writes one debug line with the operation, the attempt and the cause, so contention that the final answer hides can be seen with the log level raised. TransactWriteItems logs its last cause when it answers with the synthetic TransactionConflict reasons; those reasons are unchanged. docs/differences-from-dynamodb.md states that a single-item write on a MongoDB table with a stream or an index answers 503 after 50 attempts where the service answers 200, and that the server counts the 503 as a system error, not under TransactionConflict. Tests: retry_budget_exhausted puts the operation, the attempt count and the cause in the message; retrying returns its cause; the conversions into TxErr and TransactOpError keep the driver's text and a helper's Transient text.
The batch contention test replayed UnprocessedItems in a loop with no limit and no pause, so a server that never applies the requests would hold the job until its timeout. The loop now makes at most 200 passes, sleeps 10 ms between two, and fails with the number of requests still unapplied. test_transactions_on_a_stream_table_all_commit sends TransactWriteItems (a put, a delete and an update on disjoint keys) from six writers to a stream table. On MongoDB the transactions conflict on the stream's sequence counters, so a helper conflict has to re-run the whole transaction. The test fails when a call answers 500 or 503, resends the TransactionConflict cancellation that MongoDB answers after 50 attempts, and checks that each counter and the stream record count show every transaction applied once. No test combined TransactWriteItems with a stream before. tests/test_concurrency.py: ServiceUnavailable joins the retryable codes. A MongoDB write that cannot commit in 50 attempts answers 503 where it answered 400 TransactionConflictException, and the helper's client allows the SDK a single retry, so the single-row tests would fail on the first exhausted write. The UpdateItem contention test accepts 400 TransactionConflictException when EXTENDDB_TEST_BACKEND is cassandra, which settles a lost optimistic-concurrency race that way. Every other backend keeps the strict check.
The RFC and design document 13 listed the UnknownTransactionCommitResult label as a trigger for re-running a transaction body. The code does not re-run a body for that label: the commit may already have applied, so classify_commit_error retries commitTransaction in place. Both documents now say so. Design document 13 said a single-item write that spends its attempts returns StorageError::Internal. It returns StorageError::Transient, which the engine answers as 503 ServiceUnavailable and the server counts as a system error. The text now says that and describes the message. "Base 50 microseconds" gave a backoff shorter than the 1 ms tick of the tokio timer. Both documents now give the wait (a uniform 0 to 50 us times 2^min(attempt, 8)), its totals over 50 attempts, and the effect of the tick on the first attempts. The comments on StorageError::TransactionConflict and StorageError::Transient, and on the engine arm that maps TransactionConflict, described a single-item write that loses to other writers as TransactionConflictException. The service answers that error to a single-item write only while a TransactWriteItems holds the item, and the MongoDB backend reports the other case as Transient.
BatchWriteItem runs seven checks on each request before it writes anything: the key shape of a put, the nesting depth, the item size, the attribute name sizes, the key sizes, the vector attributes and the key shape of a delete. The tests covered the first check only, with a put whose key has the wrong type. Moving any of the other six back into the write loop, which applies the requests ahead of the faulty one before the 400, left the tests green. write_batch_tests.rs gets one test for each of the other six. Each sends a valid put followed by a request that fails that check alone (an item of 401 KB, 40 levels of nesting, an attribute name of 70,000 bytes, a partition key of 3,000 bytes, a vector of two components for an index of three, a delete whose key has an extra attribute). The test requires a 400 that carries the message of that check and requires that the storage double recorded no write, so a check that runs after the write of the put ahead of it fails the test. test_batch_write_validation_order.py adds the item of 401 KB and the delete with an extra key attribute as the second request of a batch. Both batches answer 400 ValidationException and the first put is not stored.
This commit changes comments and documents only. batch_write_item.rs: the tables come from a HashMap, so requests are checked in listed order within a table and the tables in map order, and which of two table faults is reported is not defined. The sentences that claimed an applied request is never followed by a rejection of the batch and that a rejected batch never has an applied request now cover request faults, and they name check_batch_counts and the HTTP layer for the limits on the number of requests and on the request size. A storage error that a replay does not clear still fails the batch after earlier requests were applied (a 500 for an internal error), and a batch of which no request applied answers 503. is_deferrable described Transient as a write that lost every retry against concurrent writers. Transient is a failure expected to clear on retry. The MongoDB conflict is one case; step-down and snapshot errors on MongoDB, and I/O errors, pool timeouts and busy databases on SQLite, are others. The Cassandra backend raises TransactionConflict from a put or a delete only on a table with TTL enabled. Without TTL it returns TransactionCanceled, which is not deferred, so a batch on such a table answers 400 after earlier requests were applied. The comment on the TransactionConflict test is qualified and the differences row states the case. differences-from-dynamodb.md: the two batch measurements carry their date, region and client retry setting (2026-10-27, us-east-1, retries off), and the single-item contention row carries the date of its measurement (2026-10-23). create_table.rs names MongoDB where it says a write that lost to single-item writers is reported as Transient. Cassandra reports it as TransactionConflict. Design document 13, section 5.6, said both transient-conflict signals mean the write lost to a concurrent writer. They mean nothing was applied and the transaction may be re-run, and a concurrent writer is the usual cause. After an UpdateItem version miss the exhaustion message carries a fixed sentence where other attempts carry a driver error. Design document 13, the RFC, the differences row, StorageError::Transient and retry_budget_exhausted call it the last error that ended an attempt.
The Cassandra pytest job failed two tests of tests/test_stream_write_contention.py on this branch. The batch test rejected a PutItem that answered 400 TransactionConflictException. Cassandra settles a lost optimistic-concurrency race that way once its retries are spent, and the UpdateItem test in the same file already accepts it when EXTENDDB_TEST_BACKEND names that backend. The batch test now accepts it the same way; MongoDB, SQLite and PostgreSQL keep 503 as the only allowed failure. The transaction test expected one stream record per operation of every transaction. Cassandra writes no stream record for a transacted write until PR #576 lands, so the stream held only the setup puts (60 of the 240 wanted). On that backend the test now expects the setup puts alone; the assertions that every transaction committed and that each counter holds its writer's rounds run unchanged on every backend.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #437. A write to a stream-enabled table that spends its 50 attempts under
sustained contention answers 503 here where the service answers 200; #426 tracks
retrying a lost race until it commits.
On a table with a stream enabled, every write draws a sequence number from a
per-shard counter inside the write's transaction. Two writes on the same shard
conflict on that counter, MongoDB aborts one with WriteConflict (code 112) and
the TransientTransactionError label, and
next_sequence_number_in_sessionmapped that error to
StorageError::Internal. The retry loop never saw theconflict and the request answered HTTP 500. The same mapping sat on the index
entry writes, the shard lookup and the stream record insert.
write_conflict::transaction_errornow classifies a driver error raised insidea write transaction: a transient write conflict becomes
StorageError::Transient, which the transaction bodies turn into a re-run withthe same backoff and budget as a conflict on the data collection; anything else
stays
Internal. TheUnknownTransactionCommitResultlabel is not transientand is still classified by the commit path alone, which retries
commitTransactionin place and never re-runs the body.A single-item write that spends its whole retry budget returns
StorageError::Transient, answered as HTTP 503 ServiceUnavailable, from allthree operations: PutItem, DeleteItem and UpdateItem.
TransactWriteItemskeeps
TransactionCanceledExceptionwith a per-operationTransactionConflictreason.BatchWriteItem applied its requests one after another and answered any write
error for the whole batch, so a batch whose first put had committed answered
500 and a later PutItem returned that put's item as its old image. BatchWriteItem
now checks the whole batch before it writes anything. The table lookups, the
duplicate-key check and the validation of every request run first, and a request
fault (a missing table, a key that does not match the schema, duplicate keys, an
item over the size limit) rejects the batch with a 400 and nothing applied,
wherever the faulty request sits. This also removes a partial application that
exists on main, where the requests ahead of a faulty request were written before
the 400. The limits on the number of tables and requests are checked before
that, and the request size limit by the HTTP layer. The handler then tries every
request, in the order listed within a table and in the order the map yields the
tables. A request the backend cannot apply now (a transient error such as a
spent retry budget, or a transaction conflict) does not stop the others: it is
returned in UnprocessedItems, and the batch answers 200 when at least one
request applied. A connection error ends the attempt, and the request that met
it and every request after it are returned in UnprocessedItems. A batch of which
no request applied answers 503 ServiceUnavailable. A storage error that a replay
does not clear, such as an internal error, still fails the batch with a 500
after earlier requests were applied. So does a put or a delete on a Cassandra
table without TTL that meets an item a TransactWriteItems holds: it returns
TransactionCanceled and the batch answers 400 TransactionCanceledException.
Consumed capacity and item collection metrics count the applied requests only.
A request is applied at most once: the storage layer retries before commit and
never re-runs a body after a commit of unknown outcome. Each failed request
spends its own retry budget before the batch answers, so a batch of 25
contended requests can spend 25 budgets in one call.
What the service does
Every measurement below is against DynamoDB in us-east-1 with client retries
off (
retries={"max_attempts": 0}), on existingbb-tables with a stringhash key, a GSI and a stream.
2026-10-23, one key, all writers on the same item:
ADD #c :one SET #g = :gwith ALL_NEW on one keySET l = list_append(l, :m)on one keyThe UpdateItem rows also hold for what the accepted calls did: the stored
counter was 320, equal to the number of calls that answered 200, the 320
ALL_NEW counter values were 1 to 320 with each returned to exactly one caller,
and the stored list held each of the 320 markers once, none missing and none
duplicated.
The service sheds none of this load at any writer count measured. It answers a
single-item write with
TransactionConflictExceptiononly while aTransactWriteItemsholds the item, which this backend cannot tell apart froma race against other single-item writes.
BatchWriteItem racing TransactWriteItems, 2026-10-27. Four threads looped
TransactWriteItems over two items while four threads sent BatchWriteItem calls
on the same items. Two samples of 640 batch calls each; about half of the
transactions were cancelled by each other (107 of 222 and 125 of 240), so the
items were held often.
The item no transaction touched was present after all 640 calls of both
samples. No call answered TransactionConflictException (0 of 1,280) and none
answered 200 with every request unprocessed. So the batch handler keeps
TransactionConflict deferrable, and a batch of which no request applied answers
an error, where this server answers 503.
BatchWriteItem faults, 2026-10-27. A batch of a valid put followed by a put with
a key of the wrong type, a put to a missing table, or duplicate keys in a second
table answered 400 (ValidationException, ResourceNotFoundException,
ValidationException) and applied none of its requests; a delete followed by a
put with a key of the wrong type answered 400 ValidationException and the item
was still present. Two samples of each.
What each status means here
applied and the client replays them. The API reference puts a request that
failed because the throughput was exceeded or an internal processing failure
occurred into UnprocessedItems, and lists the request faults that reject the
whole batch: a missing table, a key that does not match the schema,
duplicate keys, more than 25 requests, an item over 400 KB, a request over
16 MB, a key over the key length limits, none of which is contention. The
one error the page names for a batch that processed nothing is a throttle
(
ProvisionedThroughputExceededExceptionorThrottlingException), whichthis server does not account for. A batch of which no request could be
applied answers 503 ServiceUnavailable, which the SDKs retry with backoff, so
a client's UnprocessedItems loop does not spin on a batch that makes no
progress. The service answers 500 InternalServerError for that case (measured
below).
budget, or a batch applied none of its requests.
The error-handling documentation lists ServiceUnavailable (HTTP 503) as
"OK to retry? Yes", and the SDK retry modes retry it, whereas a 400
TransactionConflictExceptionis handed to the application.UnprocessedItems, because replaying the request does not clear it and a
client's UnprocessedItems loop would spin on it.
The known limit this PR does not change
MongoDB aborts the loser of a same-document conflict at once instead of
queueing it, so a hot key is settled by retries. Measured on MongoDB 7.0 as a
single-node replica set with a stream and GSI table, client retries off, on a
host shared with other sessions (load average 17 to 20), 64 writers x 20
PutItem on one key: 337 of 1,280 calls committed, a commit rate of 6.5 per
second; at 256 writers, 298 of 5,120, a rate of 2.4 per second. The service
answered 1,920 of 1,920 calls from 64 writers on one key in 2.5 s, about 770
per second. The difference
comes from aborting the loser rather than queueing it, which this PR does not
change. This PR changes the answer a losing write gets to a 503 the SDKs retry,
in place of a 500 or a 400. #426 tracks retrying a lost race until it commits.
Tests
tests/test_stream_write_contention.py:test_racing_puts_and_deletes_on_a_stream_table_all_succeed: 8 writers x 60operations over 4 keys of a stream table, every call answers 200, and the
stream holds one record per write that changed the item.
test_batch_writes_racing_puts_on_a_stream_table_never_fail: 16 writers, 8sending BatchWriteItem (the raced key plus a per-writer side key) and 8
sending PutItem on the raced key, 30 rounds. Even rounds put the raced
request first, odd rounds put the side request first, so both positions are
covered. A batch call answers 200 when at least one request applied and 503 when none
did, a batch that did not apply every request lists the rest in
UnprocessedItems, replaying those lands each
side item once, and the stream record count equals the number of applied
writes. A PutItem may answer 503 once it has spent its budget and nothing
else.
test_update_item_contention_is_never_a_transaction_conflict: 16 writers x20
ADD #c :one SET #g = :gand 16 x 20SET l = list_append(l, :m)on one key of a tablewith a GSI and a stream, the shape measured above. No call may answer 400
TransactionConflictException, the stored counter must equal the number ofcalls that answered 200, each ALL_NEW counter value must go to exactly one
caller, and every marker a caller was told committed must be stored once.
Engine unit test
only_errors_a_replay_may_clear_are_deferredpins thedeferrable set: a transaction conflict, a transient error and a connection
error are deferred; an internal error, a validation error, a missing table, a
failed condition and an exceeded limit are not.
Further tests on this branch:
crates/engine/src/batch_write_item/write_batch_tests.rs: a storage doublewhose first put fails with Transient, followed by a request whose key does not
match the schema, answers 400 with nothing written; the same fault in a later
table, a missing later table and duplicate keys in a later table; a valid
batch written with each table's own key information; a put that returns
TransactionConflict after another request applied leaves exactly that request
in UnprocessedItems; and one test per pre-write check (item size, nesting
depth, attribute-name size, key size, key-only delete shape) with the fault
in the second request, asserting 400 and no write. Moving any one of those
checks back inside the write loop fails its test.
crates/engine/src/batch_write_item/write_batch_deferral_tests.rs: a failedfirst request does not stop the rest; UnprocessedItems holds exactly the
failed requests per table and the consumed capacity counts the applied ones; a
double that fails every put answers 503 after trying every request, for
Transient and for TransactionConflict; a connection error leaves the rest
unattempted; an internal error after a failed request still fails the batch
with 500.
tests/test_batch_write_validation_order.py: a valid put or delete ahead ofan invalid put, a put to a missing table, a table with duplicate keys, an item
of 401 KB, or a delete with an extra key attribute answers 400 with the valid
request not applied. It needs no contention.
crates/storage-mongodb: the exhausted-budget error carries the operation,the attempt count and the last error that ended an attempt, and the
conversions into the retry loops keep the driver's text.
test_transactions_on_a_stream_table_all_commit: six writers sendTransactWriteItems (a put, a delete and an update on disjoint keys) to a
stream table. Every call must commit, a cancellation by TransactionConflict
after 50 attempts is resent, and each counter and the stream record count
show every transaction applied once.
with a failing assertion.
ServiceUnavailableis a retryable code intests/test_concurrency.py. On Cassandra the batch and UpdateItem contentiontests accept 400 TransactionConflictException for a lost single-item race,
which is that backend's answer once its retries are spent, and the
transaction test expects stream records for the setup puts only, because
Cassandra writes no record for a transacted write until PR fix(cassandra): record stream records for transacted writes #576 lands.
What fails without the fix
On main (9d13202), measured on MongoDB 7.0: both tests in
test_stream_write_contention.py fail, 88 of 480 churn writes and 9 batch calls
answering 500 InternalServerError.
The wider comparison measured while this branch was written, same backend,
client retries off:
The 503s in that table are the 50-attempt budget running out. #426 tracks
retrying a lost race until it commits. The server log on the branch carries no WriteConflict line and no
internal error for these runs.
On 879a419, the first commit of this branch, with the test file of this
branch, over 8 runs: the UpdateItem test failed 7 runs, 1 to 25 of 640 calls
answering 400 TransactionConflictException; the batch order test failed 2 runs,
both with BatchWriteItem 503 ServiceUnavailable on a round that put the raced
request first. With the fix in the tree, 8 of 8 runs passed all three tests.
Controls on the committed tree with the change reverted or the server edited to
force the case:
batch tests and all 5 original tests of
tests/test_batch_write_validation_order.pyfail; the first head's binary fails the same 5 wire tests. With item size and
the key-only delete shape moved back inside the write loop, the two tests of
those checks fail and the two new wire parameters fail at the not-applied
assertion.
removed: 5 of 14 engine batch tests fail.
the transaction: the transaction test fails with 42 of 60 transactions
answering 503.
four seconds at the 200-pass limit; the test without the limit does not finish
within 60 seconds.
three single-row classes of
tests/test_concurrency.pyfail 3 of 3 runswithout
ServiceUnavailablein the retryable codes and pass 3 of 3 with it.The batch order control is probabilistic: it fires when the first request of a
batch spends its 50-attempt budget.
Gates
cargo +1.97.0 fmt --all -- --check: clean.cargo +1.97.0 clippy --all-targets -- -D warnings: no warnings.cargo test --workspaceandcargo +1.88.0 test --workspace: 1,835 passed,0 failed, 26 ignored on each. The Cassandra integration targets ran as unit
tests only, because another process on the build host holds a Cassandra on
127.0.0.1:9042 that those targets would otherwise use.
devtools/run-tests --backend mongodb --pytest) fortest_stream_write_contention, test_batch_operations, test_item_operations
and test_put_item_create_race: 170 passed, 3 xfailed, 0 failed. The three
xfails are the lost-create-race 500s of [Bug] MongoDB: concurrent same-key PutItem surfaces E11000 as InternalServerError #356, which fix(mongodb): retry an unconditional PutItem that loses the create race #431 fixes and which
this branch's base does not carry.
shared engine paths that
batch_write_item.rsserves on every backend: 161passed, 0 failed.